/*
 * Licensed to the Apache Software Foundation (ASF) under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for additional information regarding
 * copyright ownership. The ASF licenses this file to You under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance with the License. You may obtain a
 * copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the License
 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
 * or implied. See the License for the specific language governing permissions and limitations under
 * the License.
 */
package org.apache.geode.test.compiler;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Serializable;
import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;

import javax.tools.Diagnostic;
import javax.tools.DiagnosticCollector;
import javax.tools.FileObject;
import javax.tools.ForwardingJavaFileManager;
import javax.tools.JavaCompiler;
import javax.tools.JavaFileManager;
import javax.tools.JavaFileObject;
import javax.tools.SimpleJavaFileObject;
import javax.tools.ToolProvider;

import org.apache.commons.io.IOUtils;

/**
 * Test framework utility class to programmatically create classes, JARs and ClassLoaders that
 * include the classes.
 *
 * @since GemFire 7.0
 */
@SuppressWarnings("serial")
public class ClassBuilder implements Serializable {

  private String classPath = System.getProperty("java.class.path");

  /**
   * Write a JAR with an empty class using the given class name. The className may have a package
   * separated by /. For example: my/package/myclass
   *
   * @param className Name of the class to create
   * @param outputFile Where to write the JAR file
   * @throws IOException If there is a problem creating the output stream for the JAR file.
   */
  public void writeJarFromName(final String className, final File outputFile) throws IOException {
    writeJarFromContent(className, "public class " + className + "{}", outputFile);
  }

  /**
   * Write a JAR with a class of the given name with the provided content. The className may have a
   * package separated by /. For example: my/package/myclass
   *
   * @param className Name of the class to create
   * @param content Content of the created class
   * @param outputFile Where to write the JAR file
   * @throws IOException If there is a problem writing the JAR file.
   */
  public void writeJarFromContent(final String className, final String content,
      final File outputFile) throws IOException {
    FileOutputStream fileOutputStream = new FileOutputStream(outputFile);
    writeJarFromContent(className, content, fileOutputStream);
    fileOutputStream.close();
  }

  /**
   * Create a JAR with an empty class using the given class name. The className may have a package
   * separated by /. For example: my/package/myclass
   *
   * @param className Name of the class to create
   * @return The JAR file contents
   * @throws IOException If there is a problem creating the output stream for the JAR file.
   */
  public byte[] createJarFromName(final String className) throws IOException {
    return createJarFromClassContent(className, "public class " + className + "{}");
  }

  /**
   * Create a JAR using the given file contents and with the given file name.
   *
   * @param fileName Name of the file to create
   * @param content Content of the created file
   * @return The JAR file content
   * @throws IOException If there is a problem creating the output stream for the JAR file.
   */
  public byte[] createJarFromFileContent(final String fileName, final String content)
      throws IOException {
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    JarOutputStream jarOutputStream = new JarOutputStream(byteArrayOutputStream);

    JarEntry entry = new JarEntry(fileName);
    entry.setTime(System.currentTimeMillis());
    jarOutputStream.putNextEntry(entry);
    jarOutputStream.write(content.getBytes());
    jarOutputStream.closeEntry();

    jarOutputStream.close();
    return byteArrayOutputStream.toByteArray();
  }

  /**
   * Create a JAR using the given class contents and with the given class name.
   *
   * @param className Name of the class to create
   * @param content Content of the created class
   * @return The JAR file content
   * @throws IOException If there is a problem creating the output stream for the JAR file.
   */
  public byte[] createJarFromClassContent(final String className, final String content)
      throws IOException {
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    writeJarFromContent(className, content, byteArrayOutputStream);
    return byteArrayOutputStream.toByteArray();
  }

  /**
   * Write a JAR with a class of the given name with the provided content. The className may have a
   * package separated by /. For example: my/package/myclass
   *
   * @param className Name of the class to create
   * @param content Content of the created class
   * @param outStream Stream to write the JAR to
   * @throws IOException If there is a problem creating the output stream for the JAR file.
   */
  public void writeJarFromContent(final String className, final String content,
      final OutputStream outStream) throws IOException {

    byte[] bytes = compileClass(className, content);

    createJar(className, outStream, bytes);
    return;
  }

  private void createJar(String className, OutputStream outStream, byte[] bytes)
      throws IOException {
    JarOutputStream jarOutputStream = new JarOutputStream(outStream);

    // Add the class file to the JAR file
    String formattedName = className;
    if (!formattedName.endsWith(".class")) {
      formattedName = formattedName.concat(".class");
    }

    JarEntry entry = new JarEntry(formattedName);
    entry.setTime(System.currentTimeMillis());
    jarOutputStream.putNextEntry(entry);
    jarOutputStream.write(bytes);
    jarOutputStream.closeEntry();

    jarOutputStream.close();
  }

  public static void writeJarFromClasses(File jar, Class... types) throws IOException {
    try (JarOutputStream jarOutputStream = new JarOutputStream(new FileOutputStream(jar))) {
      for (Class type : types) {
        String className = type.getName();
        String classAsPath = className.replace('.', '/') + ".class";
        InputStream stream = type.getClassLoader().getResourceAsStream(classAsPath);
        byte[] bytes = IOUtils.toByteArray(stream);

        JarEntry entry = new JarEntry(classAsPath);
        entry.setTime(System.currentTimeMillis());

        jarOutputStream.putNextEntry(entry);
        jarOutputStream.write(bytes);
        jarOutputStream.closeEntry();
      }
    }
  }


  /**
   * Creates a ClassLoader that contains an empty class with the given name using the given content.
   * The className may have a package separated by /. For example: my/package/myclass
   *
   * @param className Name of the class to create
   * @param content Content of the created class
   * @return The class loader
   * @throws IOException If there's a problem creating the output stream used to generate the class
   */
  public ClassLoader createClassLoaderFromContent(final String className, final String content)
      throws IOException {
    byte[] classDefinition = compileClass(className, content);
    SingleClassClassLoader classLoader = new SingleClassClassLoader();
    classLoader.addClass(className, classDefinition);
    return classLoader;
  }

  /**
   * Compile the provided class. The className may have a package separated by /. For example:
   * my/package/myclass
   *
   * @param className Name of the class to compile.
   * @param classCode Plain text contents of the class
   * @return The byte contents of the compiled class.
   */
  public byte[] compileClass(final String className, final String classCode) throws IOException {
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

    JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
    OutputStreamJavaFileManager<JavaFileManager> fileManager =
        new OutputStreamJavaFileManager<JavaFileManager>(
            javaCompiler.getStandardFileManager(null, null, null), byteArrayOutputStream);

    List<JavaFileObject> fileObjects = new ArrayList<JavaFileObject>();
    fileObjects.add(new JavaSourceFromString(className, classCode));

    List<String> options = Arrays.asList("-classpath", this.classPath);
    DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<JavaFileObject>();
    if (!javaCompiler.getTask(null, fileManager, diagnostics, options, null, fileObjects).call()) {
      StringBuilder errorMsg = new StringBuilder();
      for (Diagnostic d : diagnostics.getDiagnostics()) {
        String err = String.format("Compilation error: Line %d - %s%n", d.getLineNumber(),
            d.getMessage(null));
        errorMsg.append(err);
        System.err.print(err);
      }
      throw new IOException(errorMsg.toString());
    }
    return byteArrayOutputStream.toByteArray();
  }

  /***
   * Add to the ClassPath used when compiling.
   *
   * @param path Path to add
   * @return The complete, new ClassPath
   */
  public String addToClassPath(final String path) {
    this.classPath += (System.getProperty("path.separator") + path);
    return this.classPath;
  }

  /**
   * Get the current ClassPath used when compiling.
   *
   * @return The ClassPath used when compiling.
   */
  public String getClassPath() {
    return this.classPath;
  }

  private class JavaSourceFromString extends SimpleJavaFileObject {
    final String code;

    JavaSourceFromString(final String name, final String code) {
      super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
      this.code = code;
    }

    @Override
    public CharSequence getCharContent(final boolean ignoreEncodingErrors) {
      return this.code;
    }
  }

  private class OutputStreamSimpleFileObject extends SimpleJavaFileObject {
    private OutputStream outputStream;

    protected OutputStreamSimpleFileObject(final URI uri, final JavaFileObject.Kind kind,
        final OutputStream outputStream) {
      super(uri, kind);
      this.outputStream = outputStream;
    }

    @Override
    public OutputStream openOutputStream() {
      return this.outputStream;
    }
  }

  private class OutputStreamJavaFileManager<M extends JavaFileManager>
      extends ForwardingJavaFileManager<M> {
    private OutputStream outputStream;

    protected OutputStreamJavaFileManager(final M fileManager, final OutputStream outputStream) {
      super(fileManager);
      this.outputStream = outputStream;
    }

    @Override
    public JavaFileObject getJavaFileForOutput(final JavaFileManager.Location location,
        final String className, final JavaFileObject.Kind kind, final FileObject sibling) {
      return new OutputStreamSimpleFileObject(new File(className).toURI(), kind, outputStream);
    }
  }

  public class SingleClassClassLoader extends ClassLoader {
    public Class addClass(final String className, final byte[] definition) {
      return super.defineClass(className, definition, 0, definition.length);
    }
  }
}
